faq-client.tsx 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. "use client";
  2. import { useEffect, useState } from "react";
  3. import { useTranslations } from "next-intl";
  4. import { fetchCommonQuestions, type FaqItem } from "@/lib/faq-api";
  5. import { InlineLoading } from "@/components/ui/loading-state";
  6. import { cn } from "@/lib/utils";
  7. function Chevron({ className }: { className?: string }) {
  8. return (
  9. <svg
  10. aria-hidden
  11. viewBox="0 0 24 24"
  12. className={cn("h-5 w-5 shrink-0 text-slate-400", className)}
  13. fill="none"
  14. stroke="currentColor"
  15. strokeWidth="2"
  16. strokeLinecap="round"
  17. strokeLinejoin="round"
  18. >
  19. <path d="m6 9 6 6 6-6" />
  20. </svg>
  21. );
  22. }
  23. export function FaqClient() {
  24. const t = useTranslations("faq");
  25. const [items, setItems] = useState<FaqItem[] | null>(null);
  26. const [failed, setFailed] = useState(false);
  27. const [openIndex, setOpenIndex] = useState<number | null>(null);
  28. useEffect(() => {
  29. let cancelled = false;
  30. void fetchCommonQuestions().then(({ items: next, failed: err }) => {
  31. if (cancelled) return;
  32. setItems(next);
  33. setFailed(err);
  34. });
  35. return () => {
  36. cancelled = true;
  37. };
  38. }, []);
  39. return (
  40. <div className="mx-auto max-w-3xl">
  41. <div className="text-center sm:text-left">
  42. <h1 className="font-serif text-3xl font-semibold tracking-tight text-[var(--navy)] md:text-4xl">
  43. {t("title")}
  44. </h1>
  45. <p className="mt-2 text-sm text-[var(--muted)]">{t("subtitle")}</p>
  46. </div>
  47. {items === null ? (
  48. <div className="mt-10 flex justify-center sm:justify-start">
  49. <InlineLoading text={t("loading")} />
  50. </div>
  51. ) : failed ? (
  52. <p className="mt-10 rounded-xl border border-rose-200/80 bg-rose-50/80 px-4 py-3 text-sm text-rose-800">
  53. {t("loadError")}
  54. </p>
  55. ) : items.length === 0 ? (
  56. <p className="mt-10 rounded-xl border border-dashed border-[var(--border)] bg-white/60 px-4 py-8 text-center text-sm text-[var(--muted)]">
  57. {t("empty")}
  58. </p>
  59. ) : (
  60. <div className="mt-10 overflow-hidden rounded-2xl border border-[var(--border)] bg-[var(--card)] shadow-card">
  61. <ul className="divide-y divide-[var(--border)]" role="list">
  62. {items.map((item, index) => {
  63. const open = openIndex === index;
  64. return (
  65. <li key={`${item.id}-${index}`}>
  66. <button
  67. type="button"
  68. id={`faq-trigger-${index}`}
  69. aria-expanded={open}
  70. aria-controls={`faq-panel-${index}`}
  71. className={cn(
  72. "flex w-full items-start justify-between gap-4 px-5 py-4 text-left transition-colors sm:px-6 sm:py-5",
  73. open
  74. ? "bg-gradient-to-r from-blue-50/90 to-slate-50/40"
  75. : "hover:bg-slate-50/80",
  76. )}
  77. onClick={() => setOpenIndex(open ? null : index)}
  78. >
  79. <span
  80. className={cn(
  81. "min-w-0 flex-1 text-[15px] font-semibold leading-snug sm:text-base",
  82. open ? "text-[var(--navy)]" : "text-slate-800",
  83. )}
  84. >
  85. {item.question}
  86. </span>
  87. <Chevron
  88. className={cn(
  89. "mt-0.5 transition-transform duration-300 ease-out",
  90. open && "rotate-180 text-blue-600",
  91. )}
  92. />
  93. </button>
  94. <div
  95. id={`faq-panel-${index}`}
  96. role="region"
  97. aria-labelledby={`faq-trigger-${index}`}
  98. className={cn(
  99. "grid transition-[grid-template-rows] duration-300 ease-out",
  100. open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
  101. )}
  102. >
  103. <div className="min-h-0 overflow-hidden">
  104. <div className="border-t border-[var(--border)] bg-[var(--surface-muted)]/50 px-5 py-4 sm:px-6 sm:py-5">
  105. <p className="whitespace-pre-wrap text-[15px] leading-relaxed text-slate-600">
  106. {item.answer}
  107. </p>
  108. </div>
  109. </div>
  110. </div>
  111. </li>
  112. );
  113. })}
  114. </ul>
  115. </div>
  116. )}
  117. </div>
  118. );
  119. }